返回文章列表

第十四章|项目二:WebCodecs 视频抽帧与水印

实现浏览器端视频处理主链路:MP4 demux、EncodedVideoChunk、VideoDecoder、VideoFrame、Canvas/OffscreenCanvas 抽帧与水印、VideoEncoder 重新编码、mux 输出与 Worker 化工程边界。

第十四章|项目二:WebCodecs 视频抽帧与水印

这一章开始从“知道 API”进入“能做项目”。目标不是把一个完整商业级剪辑器一次性做完,而是做出一条浏览器端视频处理主链路

MP4 文件

demux 解封装,拿到视频 sample

VideoDecoder 解码

得到 VideoFrame

Canvas / OffscreenCanvas 绘制帧

抽帧生成缩略图

添加水印

VideoEncoder 重新编码

得到 EncodedVideoChunk

mux 封装成 MP4 / WebM

WebCodecs 提供的是浏览器原生的底层音视频编解码能力,可以表示原始视频帧 VideoFrame、编码后视频块 EncodedVideoChunk,并通过 VideoDecoder / VideoEncoder 在二者之间转换;它适合视频编辑、直播、视频会议等需要逐帧控制的场景。(MDN Web Docs) 但 WebCodecs 不负责从 MP4 里读出 chunk,也不负责把 chunk 写成可播放的视频文件,所以本项目必须把 demuxer 和 muxer 当成独立模块。(MDN Web Docs)


1. 本章学习目标

学完本章,你应该能讲清楚并做出一个 Demo:

  1. 用户上传 MP4 后,为什么不能直接丢给 VideoDecoder
  2. demuxer 在 WebCodecs pipeline 里的位置。
  3. 如何把 MP4 sample 转成 EncodedVideoChunk
  4. 如何用 VideoDecoder 得到 VideoFrame
  5. 如何每隔 1 秒抽一帧生成缩略图。
  6. 如何用 Canvas / OffscreenCanvas 给每帧加水印。
  7. 如何用 VideoEncoder 重新编码。
  8. 为什么编码后的 chunks 还不是一个标准 MP4 文件。
  9. 为什么必须及时 VideoFrame.close()
  10. 为什么真实项目里应该把处理放到 Worker。

2. 项目最终效果

项目名:

webcodecs-frame-watermark

核心功能:

1. 上传一个 MP4 文件
2. 解析出视频轨道
3. 使用 WebCodecs 解码视频帧
4. 每隔 1 秒生成一张缩略图
5. 对每帧添加文字水印
6. 使用 WebCodecs 重新编码视频
7. 输出 encoded chunks
8. 接入 muxer 后导出新的视频文件

界面可以先做得很简单:

+------------------------------------------------+
| 选择视频文件                                    |
| [ Upload MP4 ]                                 |
+------------------------------------------------+
| 处理进度:37%                                  |
| 当前阶段:decoding / processing / encoding      |
+------------------------------------------------+
| 缩略图列表                                      |
| [00:00] [00:01] [00:02] [00:03] ...            |
+------------------------------------------------+
| 处理结果                                        |
| encoded chunks: 184                            |
| output: waiting for muxer                      |
+------------------------------------------------+

3. 核心心智模型

3.1 WebCodecs 只管“编解码”

很多新人会误以为:

const chunks = await videoEncoder.encode(...)
saveAsMp4(chunks)

这条路是错的。

正确心智模型是:

MP4 文件不是一堆纯视频帧。
MP4 = 容器。
里面有:
- ftyp
- moov
- trak
- stbl
- mdat
- 视频 sample
- 音频 sample
- 时间戳表
- codec 配置信息

所以:

VideoDecoder 需要的是 EncodedVideoChunk
而不是整个 MP4 文件。

VideoEncoder 输出的是 EncodedVideoChunk
而不是 MP4 文件。

一句面试版回答:

WebCodecs 负责 codec 层,也就是把 encoded chunk 解码成 VideoFrame,或者把 VideoFrame 编码成 encoded chunk。MP4/WebM 这种容器的解析和生成属于 demux/mux,不是 WebCodecs 的职责。


3.2 本项目的模块边界

┌──────────────────────────────┐
│ Main Thread                  │
│ - 文件选择                    │
│ - UI 状态                     │
│ - 展示缩略图                  │
│ - 下载结果                    │
└──────────────┬───────────────┘
               │ postMessage(ArrayBuffer)

┌──────────────────────────────┐
│ Worker                       │
│                              │
│ 1. MP4 Demuxer               │
│    MP4 → video samples        │
│                              │
│ 2. VideoDecoder              │
│    samples → VideoFrame       │
│                              │
│ 3. Frame Processor           │
│    VideoFrame → Canvas 水印   │
│                              │
│ 4. Thumbnail Generator       │
│    每 1 秒导出一张 JPEG       │
│                              │
│ 5. VideoEncoder              │
│    VideoFrame → chunks        │
│                              │
│ 6. Muxer                     │
│    chunks → MP4/WebM          │
└──────────────────────────────┘

WebCodecs 可用于 Dedicated Web Worker,适合这种重处理任务;VideoEncoder 在 MDN 上也明确标注可在 Dedicated Web Worker 中使用,同时属于需要安全上下文的 API。(MDN Web Docs)


4. 必须掌握的术语表

术语含义本项目里在哪里出现
demux从容器里拆出音视频 sampleMP4 → video samples
mux把 encoded chunks 写回容器chunks → MP4/WebM
sample容器层里的编码数据单元MP4 video sample
EncodedVideoChunkWebCodecs 里的编码视频块喂给 VideoDecoder,或由 VideoEncoder 输出
VideoFrame解码后的原始视频帧Canvas 绘制、水印处理
timestamp帧的展示时间,WebCodecs 里通常用微秒抽帧、同步、编码输出
duration当前帧持续多久传递给新生成的 VideoFrame
key frame关键帧,可独立解码seek、解码起点、编码 GOP
codec string精确 codec 标识,如 avc1.4d0034decoder / encoder configure
descriptioncodec 初始化数据MP4 中 H.264 常见来自 avcC

VideoFrame 带有 timestamp、duration、格式和分辨率等元数据,并且可以渲染到 Canvas;这些 timestamp / duration 在 MDN 文档中以微秒为单位描述。(MDN Web Docs)


5. 推荐项目目录结构

webcodecs-frame-watermark/
├─ package.json
├─ tsconfig.json
├─ index.html
├─ src/
│  ├─ main.ts
│  ├─ style.css
│  ├─ types.ts
│  ├─ worker/
│  │  └─ video-worker.ts
│  ├─ demux/
│  │  ├─ mp4-demuxer.ts
│  │  └─ mp4-types.ts
│  ├─ mux/
│  │  └─ muxer-adapter.ts
│  ├─ codecs/
│  │  └─ webcodecs-pipeline.ts
│  ├─ processing/
│  │  ├─ watermark.ts
│  │  └─ thumbnail.ts
│  └─ utils/
│     ├─ time.ts
│     └─ queue.ts

这一章里的 demuxer / muxer 用 adapter 表示。原因很现实:完整 MP4 demuxer / muxer 不是几十行代码能严肃写完的,它涉及 box tree、sample table、codec config、chunk offset、timescale、fragment 等内容。前面的 Chapter 13 已经做了 MP4 Inspector,本章重点放在 WebCodecs pipeline。


6. 核心数据结构

新建:

// src/types.ts

export type ChunkType = "key" | "delta";

export interface VideoTrackInfo {
  id: number;
  codec: string;
  codedWidth: number;
  codedHeight: number;
  displayWidth: number;
  displayHeight: number;
  timescale: number;

  /**
   * 对 H.264 in MP4,通常是 avcC 里的 AVCDecoderConfigurationRecord。
   * 对不同 codec,description 的含义不同。
   */
  description?: Uint8Array;

  durationUs?: number;
  frameRate?: number;
}

export interface DemuxedVideoSample {
  data: Uint8Array;

  /**
   * WebCodecs timestamp 使用微秒。
   * 对 MP4 来说,一般由 sample 的 composition time 转换而来。
   */
  timestampUs: number;

  durationUs: number;
  type: ChunkType;
}

export interface WatermarkOptions {
  text: string;
  fontSizeRatio: number;
  marginRatio: number;
  opacity: number;
}

export interface ProcessOptions {
  watermark: WatermarkOptions;
  thumbnailIntervalUs: number;
  outputCodec: string;
  bitrate: number;
  framerate: number;
}

export type WorkerRequest =
  | {
      type: "start";
      buffer: ArrayBuffer;
      options: ProcessOptions;
    }
  | {
      type: "cancel";
    };

export type WorkerResponse =
  | {
      type: "ready";
    }
  | {
      type: "progress";
      phase: "demuxing" | "decoding" | "processing" | "encoding" | "muxing" | "done";
      progress: number;
    }
  | {
      type: "thumbnail";
      timestampUs: number;
      mimeType: string;
      buffer: ArrayBuffer;
    }
  | {
      type: "encoded-chunk";
      timestampUs: number;
      typeOfChunk: ChunkType;
      byteLength: number;
    }
  | {
      type: "done";
      output?: ArrayBuffer;
      mimeType?: string;
      encodedChunkCount: number;
    }
  | {
      type: "error";
      message: string;
      stack?: string;
    };

7. Main Thread:上传文件并交给 Worker

// src/main.ts

import type { WorkerRequest, WorkerResponse, ProcessOptions } from "./types";

const fileInput = document.querySelector<HTMLInputElement>("#file")!;
const progressEl = document.querySelector<HTMLDivElement>("#progress")!;
const thumbsEl = document.querySelector<HTMLDivElement>("#thumbs")!;
const logEl = document.querySelector<HTMLPreElement>("#log")!;

const worker = new Worker(new URL("./worker/video-worker.ts", import.meta.url), {
  type: "module",
});

worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
  const msg = event.data;

  switch (msg.type) {
    case "ready":
      log("Worker ready");
      break;

    case "progress":
      progressEl.textContent = `${msg.phase}: ${(msg.progress * 100).toFixed(1)}%`;
      break;

    case "thumbnail": {
      const blob = new Blob([msg.buffer], { type: msg.mimeType });
      const url = URL.createObjectURL(blob);

      const item = document.createElement("figure");
      const img = document.createElement("img");
      const caption = document.createElement("figcaption");

      img.src = url;
      img.width = 160;
      caption.textContent = formatUs(msg.timestampUs);

      item.append(img, caption);
      thumbsEl.append(item);
      break;
    }

    case "encoded-chunk":
      log(
        `chunk ${msg.typeOfChunk}, ts=${formatUs(msg.timestampUs)}, bytes=${msg.byteLength}`,
      );
      break;

    case "done":
      log(`Done. encoded chunks = ${msg.encodedChunkCount}`);

      if (msg.output && msg.mimeType) {
        const blob = new Blob([msg.output], { type: msg.mimeType });
        const url = URL.createObjectURL(blob);

        const a = document.createElement("a");
        a.href = url;
        a.download = msg.mimeType.includes("mp4")
          ? "watermarked.mp4"
          : "watermarked.webm";
        a.textContent = "下载处理后的视频";
        document.body.append(a);
      }
      break;

    case "error":
      log(`Error: ${msg.message}\n${msg.stack ?? ""}`);
      break;
  }
};

fileInput.addEventListener("change", async () => {
  const file = fileInput.files?.[0];
  if (!file) return;

  thumbsEl.innerHTML = "";
  logEl.textContent = "";

  if (!("VideoDecoder" in globalThis) || !("VideoEncoder" in globalThis)) {
    log("当前浏览器不支持 WebCodecs VideoDecoder / VideoEncoder");
    return;
  }

  const buffer = await file.arrayBuffer();

  const options: ProcessOptions = {
    watermark: {
      text: "WebCodecs Demo",
      fontSizeRatio: 0.035,
      marginRatio: 0.03,
      opacity: 0.72,
    },
    thumbnailIntervalUs: 1_000_000,
    outputCodec: "avc1.42001f",
    bitrate: 2_000_000,
    framerate: 30,
  };

  const request: WorkerRequest = {
    type: "start",
    buffer,
    options,
  };

  worker.postMessage(request, [buffer]);
});

function log(text: string) {
  logEl.textContent += `${text}\n`;
}

function formatUs(us: number): string {
  const totalSeconds = Math.floor(us / 1_000_000);
  const mm = String(Math.floor(totalSeconds / 60)).padStart(2, "0");
  const ss = String(totalSeconds % 60).padStart(2, "0");
  return `${mm}:${ss}`;
}

这里用 postMessage(request, [buffer])ArrayBuffer transfer 到 Worker。大文件视频不要复制来复制去,不然很容易造成主线程卡顿和内存暴涨。


8. Demuxer Adapter:把 MP4 变成 video samples

这里先定义接口:

// src/demux/mp4-demuxer.ts

import type { DemuxedVideoSample, VideoTrackInfo } from "../types";

export interface Mp4Demuxer {
  getVideoTrack(): Promise<VideoTrackInfo>;
  videoSamples(): AsyncIterable<DemuxedVideoSample>;
}

/**
 * 这是一个 adapter 壳。
 *
 * 真实项目中你可以:
 * 1. 接入现成 demuxer
 * 2. 基于 Chapter 13 的 MP4 box parser 继续实现 sample table 解析
 * 3. 用 WASM / JS demuxer
 */
export class Mp4DemuxerAdapter implements Mp4Demuxer {
  constructor(private readonly buffer: ArrayBuffer) {}

  async getVideoTrack(): Promise<VideoTrackInfo> {
    // 伪代码:
    // 1. 解析 ftyp / moov
    // 2. 找到 video trak
    // 3. 从 stsd 中拿 codec box,例如 avc1 / hvc1 / av01
    // 4. 从 avcC / hvcC / av1C 中拿 decoder description
    // 5. 读取 width / height / timescale / duration

    throw new Error("请在这里接入真实 MP4 demuxer");
  }

  async *videoSamples(): AsyncIterable<DemuxedVideoSample> {
    // 伪代码:
    // for each sample in video track:
    //   const data = readSampleBytesFromMdat(sample.offset, sample.size)
    //   const timestampUs = timescaleToUs(sample.cts, track.timescale)
    //   const durationUs = timescaleToUs(sample.duration, track.timescale)
    //   const type = sample.isSync ? "key" : "delta"
    //   yield { data, timestampUs, durationUs, type }

    throw new Error("请在这里输出 DemuxedVideoSample");
  }
}

export function timescaleToUs(value: number, timescale: number): number {
  return Math.round((value * 1_000_000) / timescale);
}

这里最容易卡在哪里?

MP4 里的时间不是天然用微秒表示,而是用 track 自己的 timescale 表示。

例如:

timescale = 90000
sample cts = 180000

换算成秒:

180000 / 90000 = 2 秒

换算成 WebCodecs 常用的微秒:

2 * 1_000_000 = 2_000_000 us

所以 demuxer 输出给 WebCodecs 的 sample 应该带上:

{
  timestampUs: 2_000_000,
  durationUs: 33_333,
}

9. H.264 in MP4 的一个重点:description

如果 MP4 里是 H.264,通常 sample data 是 AVC 格式,不是 Annex B 格式。对 WebCodecs 来说,H.264 的 VideoDecoderConfig.description 如果存在,会被当作 AVCDecoderConfigurationRecord;这个格式常见于 MP4 文件。(W3C)

简化理解:

MP4 里的 avcC box

取出 AVCDecoderConfigurationRecord

放进 VideoDecoder.configure({ description })

伪代码:

const decoderConfig: VideoDecoderConfig = {
  codec: track.codec, // 例如 "avc1.4d401f"
  codedWidth: track.codedWidth,
  codedHeight: track.codedHeight,
  description: track.description,
};

const support = await VideoDecoder.isConfigSupported(decoderConfig);

if (!support.supported) {
  throw new Error(`Decoder not supported: ${track.codec}`);
}

decoder.configure(support.config);

这也是为什么“手写 MP4 parser 只解析 box 名字”还不够。想喂给 WebCodecs,需要拿到 codec string、sample bytes、timestamp、duration、keyframe 信息,以及 codec 初始化数据。


10. Watermark:把 VideoFrame 绘制到 OffscreenCanvas

// src/processing/watermark.ts

import type { WatermarkOptions } from "../types";

export class WatermarkRenderer {
  private readonly canvas: OffscreenCanvas;
  private readonly ctx: OffscreenCanvasRenderingContext2D;

  constructor(
    private readonly width: number,
    private readonly height: number,
  ) {
    this.canvas = new OffscreenCanvas(width, height);

    const ctx = this.canvas.getContext("2d");
    if (!ctx) {
      throw new Error("Cannot create 2D context from OffscreenCanvas");
    }

    this.ctx = ctx;
  }

  render(frame: VideoFrame, options: WatermarkOptions): VideoFrame {
    const { ctx, canvas, width, height } = this;

    ctx.clearRect(0, 0, width, height);
    ctx.drawImage(frame, 0, 0, width, height);

    const fontSize = Math.max(16, Math.round(width * options.fontSizeRatio));
    const margin = Math.round(width * options.marginRatio);

    ctx.save();
    ctx.globalAlpha = options.opacity;
    ctx.font = `${fontSize}px sans-serif`;
    ctx.textBaseline = "bottom";

    const metrics = ctx.measureText(options.text);
    const x = width - metrics.width - margin;
    const y = height - margin;

    // 描边让水印在亮色 / 暗色背景上都能看清。
    ctx.lineWidth = Math.max(2, Math.round(fontSize / 8));
    ctx.strokeStyle = "rgba(0, 0, 0, 0.75)";
    ctx.fillStyle = "rgba(255, 255, 255, 0.9)";
    ctx.strokeText(options.text, x, y);
    ctx.fillText(options.text, x, y);
    ctx.restore();

    return new VideoFrame(canvas, {
      timestamp: frame.timestamp,
      duration: frame.duration ?? undefined,
    });
  }

  async exportThumbnail(
    timestampUs: number,
    type = "image/jpeg",
    quality = 0.82,
  ): Promise<{
    timestampUs: number;
    mimeType: string;
    buffer: ArrayBuffer;
  }> {
    const blob = await this.canvas.convertToBlob({
      type,
      quality,
    });

    return {
      timestampUs,
      mimeType: type,
      buffer: await blob.arrayBuffer(),
    };
  }
}

注意顺序:

const processedFrame = watermarkRenderer.render(inputFrame, options);

encoder.encode(processedFrame);

processedFrame.close();
inputFrame.close();

VideoFrame.close() 会释放对底层媒体资源的引用;这不是“可有可无的优化”,而是 WebCodecs 项目里非常关键的内存管理动作。(MDN Web Docs) W3C 规范也提示 codec 使用的 CPU/GPU/硬件资源可能会很快耗尽,不再需要时应尽快释放。(W3C)


11. Thumbnail:每隔 1 秒抽一帧

// src/processing/thumbnail.ts

export class ThumbnailScheduler {
  private lastTimestampUs = Number.NEGATIVE_INFINITY;

  constructor(private readonly intervalUs: number) {}

  shouldCapture(timestampUs: number): boolean {
    if (timestampUs - this.lastTimestampUs >= this.intervalUs) {
      this.lastTimestampUs = timestampUs;
      return true;
    }

    return false;
  }
}

用 timestamp 判断,而不是用“第几帧”判断。

原因:

30fps 视频:
第 30 帧约等于 1 秒

24fps 视频:
第 24 帧约等于 1 秒

可变帧率视频:
第 N 帧不一定能稳定对应某个时间点

所以抽帧逻辑应该更偏时间:

timestamp >= 上一次缩略图 timestamp + 1_000_000 微秒

12. Queue 工具:处理背压

WebCodecs 的编码和解码是异步队列模型。MDN 文档说明 configure()decode()encode()flush() 等方法会进入异步处理队列,flush() 用于等待已经排队的任务完成。(MDN Web Docs) 项目里不能无限制地 decode()encode(),否则队列和内存都会涨。

// src/utils/queue.ts

export async function waitUntil(
  predicate: () => boolean,
  intervalMs = 4,
): Promise<void> {
  while (!predicate()) {
    await sleep(intervalMs);
  }
}

export function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => window.setTimeout(resolve, ms));
}

export async function waitForDecoderQueue(
  decoder: VideoDecoder,
  maxQueueSize = 8,
): Promise<void> {
  await waitUntil(() => decoder.decodeQueueSize <= maxQueueSize);
}

export async function waitForEncoderQueue(
  encoder: VideoEncoder,
  maxQueueSize = 8,
): Promise<void> {
  await waitUntil(() => encoder.encodeQueueSize <= maxQueueSize);
}

13. Muxer Adapter:为什么 encoded chunks 还不能直接保存?

VideoEncoder 输出的是:

EncodedVideoChunk

它里面有:

- 压缩后的视频字节
- timestamp
- duration
- type: key / delta

但 MP4 文件还需要:

- ftyp
- moov
- mvhd
- trak
- mdia
- minf
- stbl
- stsd
- stts
- stsc
- stsz
- stco / co64
- stss
- mdat

所以需要 muxer。

// src/mux/muxer-adapter.ts

export interface EncodedVideoSampleForMuxer {
  data: Uint8Array;
  timestampUs: number;
  durationUs?: number;
  type: "key" | "delta";
}

export interface MuxerAdapter {
  addVideoChunk(
    chunk: EncodedVideoChunk,
    metadata?: EncodedVideoChunkMetadata,
  ): void;

  finalize(): Promise<{
    mimeType: string;
    buffer: ArrayBuffer;
  }>;
}

/**
 * 教学版:只收集 chunks,不真正生成 MP4。
 * 真实项目中这里应该接 MP4/WebM muxer。
 */
export class CollectOnlyMuxer implements MuxerAdapter {
  private readonly chunks: EncodedVideoSampleForMuxer[] = [];

  addVideoChunk(
    chunk: EncodedVideoChunk,
    _metadata?: EncodedVideoChunkMetadata,
  ): void {
    const data = new Uint8Array(chunk.byteLength);
    chunk.copyTo(data);

    this.chunks.push({
      data,
      timestampUs: chunk.timestamp,
      durationUs: chunk.duration ?? undefined,
      type: chunk.type,
    });
  }

  async finalize(): Promise<{
    mimeType: string;
    buffer: ArrayBuffer;
  }> {
    // 注意:这不是标准视频文件,只是教学用二进制拼接。
    // 真实 muxer 需要生成合法 MP4/WebM 容器结构。
    const total = this.chunks.reduce((sum, item) => sum + item.data.byteLength, 0);
    const output = new Uint8Array(total);

    let offset = 0;
    for (const item of this.chunks) {
      output.set(item.data, offset);
      offset += item.data.byteLength;
    }

    return {
      mimeType: "application/octet-stream",
      buffer: output.buffer,
    };
  }

  get chunkCount(): number {
    return this.chunks.length;
  }
}

面试时可以这样说:

我这个 Demo 的核心是 WebCodecs 解码、逐帧处理和重新编码。真正导出 MP4 还需要 muxer,因为 EncodedVideoChunk 只是编码数据,不包含 MP4 容器所需的 box、sample table、track metadata 和 chunk offset 等信息。


14. Worker:完整 pipeline 骨架

// src/worker/video-worker.ts

import type {
  WorkerRequest,
  WorkerResponse,
  ProcessOptions,
  DemuxedVideoSample,
  VideoTrackInfo,
} from "../types";
import { Mp4DemuxerAdapter } from "../demux/mp4-demuxer";
import { WatermarkRenderer } from "../processing/watermark";
import { ThumbnailScheduler } from "../processing/thumbnail";
import { CollectOnlyMuxer } from "../mux/muxer-adapter";
import { waitForDecoderQueue, waitForEncoderQueue } from "../utils/queue";

let cancelled = false;

post({
  type: "ready",
});

self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
  const msg = event.data;

  if (msg.type === "cancel") {
    cancelled = true;
    return;
  }

  if (msg.type !== "start") return;

  cancelled = false;

  try {
    await runPipeline(msg.buffer, msg.options);
  } catch (error) {
    const err = error instanceof Error ? error : new Error(String(error));

    post({
      type: "error",
      message: err.message,
      stack: err.stack,
    });
  }
};

async function runPipeline(
  buffer: ArrayBuffer,
  options: ProcessOptions,
): Promise<void> {
  assertWebCodecsAvailable();

  postProgress("demuxing", 0);

  const demuxer = new Mp4DemuxerAdapter(buffer);
  const track = await demuxer.getVideoTrack();

  const watermarkRenderer = new WatermarkRenderer(
    track.displayWidth,
    track.displayHeight,
  );

  const thumbnailScheduler = new ThumbnailScheduler(options.thumbnailIntervalUs);
  const muxer = new CollectOnlyMuxer();

  let decodedFrames = 0;
  let encodedChunks = 0;

  const pendingFrameTasks = new Set<Promise<void>>();

  const decoder = new VideoDecoder({
    output: (frame) => {
      const task = handleDecodedFrame({
        frame,
        track,
        options,
        watermarkRenderer,
        thumbnailScheduler,
        encoder,
      }).finally(() => {
        pendingFrameTasks.delete(task);
      });

      pendingFrameTasks.add(task);
    },
    error: (error) => {
      throw error;
    },
  });

  const encoder = new VideoEncoder({
    output: (chunk, metadata) => {
      encodedChunks += 1;
      muxer.addVideoChunk(chunk, metadata);

      post({
        type: "encoded-chunk",
        timestampUs: chunk.timestamp,
        typeOfChunk: chunk.type,
        byteLength: chunk.byteLength,
      });
    },
    error: (error) => {
      throw error;
    },
  });

  const decoderConfig: VideoDecoderConfig = {
    codec: track.codec,
    codedWidth: track.codedWidth,
    codedHeight: track.codedHeight,
    description: track.description,
  };

  const decoderSupport = await VideoDecoder.isConfigSupported(decoderConfig);

  if (!decoderSupport.supported || !decoderSupport.config) {
    throw new Error(`当前环境不支持解码:${track.codec}`);
  }

  decoder.configure(decoderSupport.config);

  const encoderConfig: VideoEncoderConfig = {
    codec: options.outputCodec,
    width: track.displayWidth,
    height: track.displayHeight,
    bitrate: options.bitrate,
    framerate: options.framerate,
    hardwareAcceleration: "prefer-hardware",
  };

  const encoderSupport = await VideoEncoder.isConfigSupported(encoderConfig);

  if (!encoderSupport.supported || !encoderSupport.config) {
    throw new Error(`当前环境不支持编码:${options.outputCodec}`);
  }

  encoder.configure(encoderSupport.config);

  postProgress("decoding", 0);

  for await (const sample of demuxer.videoSamples()) {
    if (cancelled) break;

    await waitForDecoderQueue(decoder, 8);

    decoder.decode(toEncodedVideoChunk(sample));

    decodedFrames += 1;

    if (decodedFrames % 30 === 0) {
      postProgress("decoding", estimateProgress(sample, track));
    }
  }

  await decoder.flush();

  // decoder.flush() 只保证 decoder 输出已经吐出来;
  // output callback 里的异步 Canvas / thumbnail / encode 任务还要等。
  await Promise.all(pendingFrameTasks);

  postProgress("encoding", 0.95);

  await encoder.flush();

  decoder.close();
  encoder.close();

  postProgress("muxing", 0.98);

  const output = await muxer.finalize();

  post({
    type: "done",
    output: output.buffer,
    mimeType: output.mimeType,
    encodedChunkCount: encodedChunks,
  });

  postProgress("done", 1);
}

function toEncodedVideoChunk(sample: DemuxedVideoSample): EncodedVideoChunk {
  return new EncodedVideoChunk({
    type: sample.type,
    timestamp: sample.timestampUs,
    duration: sample.durationUs,
    data: sample.data,
  });
}

async function handleDecodedFrame(params: {
  frame: VideoFrame;
  track: VideoTrackInfo;
  options: ProcessOptions;
  watermarkRenderer: WatermarkRenderer;
  thumbnailScheduler: ThumbnailScheduler;
  encoder: VideoEncoder;
}): Promise<void> {
  const {
    frame,
    options,
    watermarkRenderer,
    thumbnailScheduler,
    encoder,
  } = params;

  try {
    postProgress("processing", 0);

    const processedFrame = watermarkRenderer.render(frame, options.watermark);

    try {
      if (thumbnailScheduler.shouldCapture(frame.timestamp)) {
        const thumbnail = await watermarkRenderer.exportThumbnail(frame.timestamp);

        post(
          {
            type: "thumbnail",
            timestampUs: thumbnail.timestampUs,
            mimeType: thumbnail.mimeType,
            buffer: thumbnail.buffer,
          },
          [thumbnail.buffer],
        );
      }

      await waitForEncoderQueue(encoder, 8);

      const shouldForceKeyFrame = shouldInsertKeyFrame(frame.timestamp);
      encoder.encode(processedFrame, {
        keyFrame: shouldForceKeyFrame,
      });
    } finally {
      processedFrame.close();
    }
  } finally {
    frame.close();
  }
}

function shouldInsertKeyFrame(timestampUs: number): boolean {
  // 简单策略:每 2 秒插入一个关键帧。
  // 真实项目中应该结合 framerate / GOP / seek 需求。
  return timestampUs % 2_000_000 < 40_000;
}

function estimateProgress(sample: DemuxedVideoSample, track: VideoTrackInfo): number {
  if (!track.durationUs) return 0;
  return Math.min(sample.timestampUs / track.durationUs, 1);
}

function assertWebCodecsAvailable(): void {
  if (!("VideoDecoder" in globalThis)) {
    throw new Error("当前环境不支持 VideoDecoder");
  }

  if (!("VideoEncoder" in globalThis)) {
    throw new Error("当前环境不支持 VideoEncoder");
  }
}

function post(message: WorkerResponse, transfer?: Transferable[]): void {
  self.postMessage(message, transfer ?? []);
}

function postProgress(
  phase: Extract<WorkerResponse, { type: "progress" }>["phase"],
  progress: number,
): void {
  post({
    type: "progress",
    phase,
    progress,
  });
}

15. Timestamp 如何传递?

本项目最重要的一条规则:

demuxer 读出来的时间戳

EncodedVideoChunk.timestamp

VideoDecoder 输出的 VideoFrame.timestamp

处理后新建 VideoFrame 时继续沿用 timestamp

VideoEncoder 输出的 EncodedVideoChunk.timestamp

muxer 根据 timestamp 写 sample timing table

也就是:

const chunk = new EncodedVideoChunk({
  timestamp: sample.timestampUs,
  duration: sample.durationUs,
  data: sample.data,
  type: sample.type,
});

const processedFrame = new VideoFrame(canvas, {
  timestamp: frame.timestamp,
  duration: frame.duration ?? undefined,
});

不要这么干:

// 错误示例:用 Date.now() 生成媒体时间戳
new VideoFrame(canvas, {
  timestamp: Date.now(),
});

Date.now() 是现实世界时间,不是媒体时间线。媒体时间线应该来自文件本身,或者来自你自己构造的稳定帧率逻辑。


16. 为什么要用 Worker?

因为这个项目有几件非常重的事情:

1. 读取大文件
2. demux
3. decode
4. Canvas 绘制
5. 逐帧添加水印
6. encode
7. mux

如果全部放主线程,用户会看到:

- 页面卡住
- 按钮点不动
- 缩略图不更新
- 进度条假死
- 浏览器提示页面无响应

Worker 版本的好处:

主线程:
- 管 UI
- 管交互
- 展示进度和缩略图

Worker:
- 管重计算
- 管 WebCodecs
- 管 Canvas 处理
- 管 mux/demux

17. 性能优化建议

17.1 控制队列长度

不要这样:

for await (const sample of demuxer.videoSamples()) {
  decoder.decode(toEncodedVideoChunk(sample));
}

大文件会疯狂堆队列。

应该这样:

for await (const sample of demuxer.videoSamples()) {
  await waitForDecoderQueue(decoder, 8);
  decoder.decode(toEncodedVideoChunk(sample));
}

17.2 用 OffscreenCanvas

Worker 里无法直接操作 DOM canvas,但可以使用 OffscreenCanvas 做逐帧绘制。

const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext("2d");

17.3 及时 close VideoFrame

每个 VideoFrame 用完就关:

try {
  // draw / encode / thumbnail
} finally {
  frame.close();
}

VideoEncoder.close() 也会结束待处理工作并释放系统资源,MDN 对 VideoEncoder.close() 的描述就是释放相关系统资源。(MDN Web Docs)

17.4 缩略图不要生成太密

别每帧都导出 JPEG。

30fps,10 分钟视频:
30 * 60 * 10 = 18000 张图

每秒一张已经很多:

10 分钟视频:
60 * 10 = 600 张图

真实产品里通常还会限制最多生成多少张,比如最多 100 张。

17.5 大视频要分段处理

完整视频一次性读入内存是 Demo 做法。

真实项目应该考虑:

- 分片读取
- streaming demux
- 边 demux 边 decode
- 边 encode 边 mux
- 及时释放中间数据

18. 常见坑

坑 1:把 MP4 文件直接传给 VideoDecoder

错误理解:

MP4 = 视频编码数据

正确理解:

MP4 = 容器
VideoDecoder 要的是编码 chunk

坑 2:忘记 decoder description

H.264 in MP4 常常需要从 avcC 里拿初始化数据,配置给 decoder。

decoder.configure({
  codec: "avc1.4d401f",
  codedWidth: 1920,
  codedHeight: 1080,
  description: avcCBytes,
});

坑 3:timestamp 单位错了

MP4 track timescale:

90000

WebCodecs timestamp:

微秒

错误:

timestamp: sample.cts

正确:

timestamp: Math.round(sample.cts * 1_000_000 / timescale)

坑 4:处理后新建 VideoFrame 时丢了 timestamp

错误:

const processed = new VideoFrame(canvas);

正确:

const processed = new VideoFrame(canvas, {
  timestamp: frame.timestamp,
  duration: frame.duration ?? undefined,
});

坑 5:忘记 close

错误:

function onFrame(frame: VideoFrame) {
  encoder.encode(frame);
}

正确:

function onFrame(frame: VideoFrame) {
  try {
    encoder.encode(frame);
  } finally {
    frame.close();
  }
}

坑 6:以为 encoded chunks 拼起来就是 MP4

错误:

const blob = new Blob(chunks, { type: "video/mp4" });

这通常不是合法 MP4。

正确:

EncodedVideoChunk[]

muxer

合法 MP4/WebM 文件

坑 7:忽略浏览器兼容性

VideoEncoder 在 MDN 上仍被标为 Limited availability,不是所有主流浏览器环境都完整可用;项目里应该用 VideoEncoder.isConfigSupported() / VideoDecoder.isConfigSupported() 做运行时检测。(MDN Web Docs)


19. 面试可能怎么问?

Q1:WebCodecs 能不能直接处理 MP4 文件?

不能。WebCodecs 处理的是 codec 层的数据,比如 EncodedVideoChunkVideoFrame。MP4 是容器,需要先 demux,把里面的视频 sample、timestamp、codec config 拆出来,再喂给 VideoDecoder


Q2:为什么 VideoEncoder 输出的 chunk 不能直接保存成 MP4?

因为 EncodedVideoChunk 只是压缩后的视频数据,不包含 MP4 容器结构。MP4 还需要 ftypmoovtrakstblmdat 等 box,以及 sample timing、chunk offset、sync sample 等表。要保存为 MP4,必须经过 muxer。


Q3:抽帧为什么用 timestamp,而不是用 frame index?

因为不同视频帧率不同,还有可变帧率视频。第 30 帧不一定永远等于 1 秒。用 timestamp 可以按真实媒体时间线抽帧,比如每隔 1_000_000us 抽一张。


Q4:水印处理后,为什么新建 VideoFrame 时要保留 timestamp?

因为编码器输出的新 chunk 需要保持原视频的时间线。如果处理后帧丢了 timestamp,muxer 后续就不知道每帧应该在什么时候展示,轻则播放速度异常,重则音视频不同步。


Q5:为什么要调用 VideoFrame.close()?

因为 VideoFrame 可能引用底层 CPU/GPU/解码器资源。逐帧处理视频时,如果不及时 close,内存和硬件资源会快速累积,可能导致卡顿、解码停滞或页面崩溃。


Q6:Worker 在这个项目里解决什么问题?

Worker 把 demux、decode、Canvas 绘制、encode、mux 等重任务从主线程移走,主线程只负责 UI 和交互。这样可以减少页面卡死,提高用户体验。


Q7:如果要保留原视频音频,怎么做?

有两种方案:

方案一:只处理视频轨,音频轨不解码,直接 remux
- demux 视频 sample
- demux 音频 sample
- 视频 decode/process/encode
- 音频 sample 原样写入 muxer
- mux 成新 MP4

方案二:音频也处理
- demux 音频 sample
- AudioDecoder 解码
- Web Audio / AudioData 处理
- AudioEncoder 重新编码
- mux 成新 MP4

如果只是加视频水印,通常优先选择方案一,避免音频重编码带来的质量损失和性能开销。


20. 实践任务

任务 1:先跑通浏览器能力检测

实现:

async function checkWebCodecs() {
  const hasDecoder = "VideoDecoder" in globalThis;
  const hasEncoder = "VideoEncoder" in globalThis;

  if (!hasDecoder || !hasEncoder) {
    return false;
  }

  const result = await VideoEncoder.isConfigSupported({
    codec: "avc1.42001f",
    width: 1280,
    height: 720,
    bitrate: 1_000_000,
    framerate: 30,
  });

  return result.supported === true;
}

目标:

页面显示:
- 是否支持 VideoDecoder
- 是否支持 VideoEncoder
- 是否支持目标编码 codec

任务 2:接入 demuxer,打印 video track 信息

输出:

codec: avc1.4d401f
width: 1920
height: 1080
timescale: 90000
duration: 12.34s
description bytes: 45

任务 3:解码并抽取第一帧

先不要编码,先完成:

MP4 → demux → VideoDecoder → VideoFrame → Canvas → JPEG

成功标准:

页面上能展示视频第一帧

任务 4:每隔 1 秒生成缩略图

成功标准:

10 秒视频大约生成 10 张缩略图
每张图下面显示时间戳

任务 5:添加水印并重新编码

成功标准:

VideoEncoder output callback 能持续收到 EncodedVideoChunk
日志能看到 key / delta chunk

任务 6:接入 muxer 导出文件

成功标准:

点击下载
得到 watermarked.mp4 或 watermarked.webm
本地播放器可以播放
水印存在
时长正确

21. 自测题

题 1:WebCodecs 为什么不直接接受 MP4 文件?

答案:因为 MP4 是容器,里面包含 box、track、sample table、mdat 等结构。WebCodecs 的 VideoDecoder 接受的是编码后的视频 chunk,也就是 EncodedVideoChunk,所以需要 demuxer 先从 MP4 中拆出视频 sample。


题 2:VideoFrame.close() 应该什么时候调用?

答案:当这个帧已经不再需要时调用。比如已经绘制到 Canvas、已经生成新帧、或者已经传给 encoder 后,就应该 close。常见写法是放在 finally 中,避免异常路径泄漏资源。


题 3:为什么抽帧应该按 timestamp,而不是按第 N 帧?

答案:因为不同视频 fps 不同,还有可变帧率视频。timestamp 代表媒体时间线,用它可以稳定表达“每隔 1 秒抽一帧”。


题 4:重新编码后为什么还需要 muxer?

答案:因为编码器输出的是 EncodedVideoChunk,不是 MP4 文件。MP4 需要容器结构和 sample metadata,例如 track、duration、sample size、sample timing、chunk offset、sync sample 等信息。


题 5:处理后新建 VideoFrame 时最容易漏掉什么?

答案:timestamp 和 duration。漏掉后,编码输出的 chunk 可能没有正确媒体时间,后续 mux 和播放都会出问题。


22. 本章总结

这一章你做的是一个非常典型的浏览器端视频处理项目:

上传 MP4

demux

EncodedVideoChunk

VideoDecoder

VideoFrame

Canvas / OffscreenCanvas

抽帧 + 水印

VideoEncoder

EncodedVideoChunk

mux

导出文件

这一章最关键的 5 句话:

  1. WebCodecs 是 codec API,不是 MP4 parser。
  2. 输入 MP4 要 demux,输出 MP4 要 mux。
  3. timestamp 必须从 demuxer 一路传到 encoder / muxer。
  4. VideoFrame 用完必须 close。
  5. 真实项目应放到 Worker,避免卡主线程。

23. 下一章衔接

下一章是:

Chapter 15:项目三:Web Audio 多轨混音器

Chapter 14 解决的是视频帧处理:

VideoFrame → Canvas → VideoEncoder

Chapter 15 会解决音频合成:

AudioBuffer / AudioNode → OfflineAudioContext → WAV / encoded audio

学完 Chapter 14 和 Chapter 15,你就能把 JD 里的这几个关键词连起来:

WebCodecs
Web Audio
编解码
合成
抽帧
水印
混音
浏览器端音视频处理